iT邦幫忙

2025 iThome 鐵人賽

DAY 28
0
Modern Web

Line Bot × NestJS:30 天開發日記系列 第 28

Day 28:LIFF 會員註冊 - ID Token 驗證與資料庫整合

  • 分享至 

  • xImage
  •  

2025 鐵人賽背景圖

前言

今天的開發重點是建立會員卡系統,將 LIFF 前端與 NestJS 後端完整串接。

前端使用 Vue 建立註冊表單,搭配 vee-validator 驗證欄位的合法性。當用戶完成填寫並點擊註冊後,系統將 LIFF 取得的 ID Token 傳遞至後端。

後端接收 ID Token 後,結合 LIFF Channel ID 透過 LINE 驗證 API 進行身份驗證。驗證成功後解析 ID Token 中的用戶編號,並以此編號作為用戶資料表的主鍵,完成前後端串接的範例應用。所有資料庫操作都透過 Supabase 處理。明天將整合電子郵件驗證碼功能,強化註冊流程的安全性。

LIFF 前端會員卡流程

LIFF 會員卡註冊頁面

完整影片連結

前端 LIFF 的部分主要拆分成三個頁面處理:

  1. 註冊頁面:使用 vee-validate 進行表單驗證,檢查欄位格式與必填項目。
  2. 驗證碼驗證頁面:透過電子郵件發送驗證碼,完成身份驗證。
  3. 完成註冊頁面:顯示註冊成功的會員資訊。

LINE 提供兩種主要的用戶資料驗證方式:

  1. 使用 ID Token 取得用戶資料
  2. 使用 Access Token 取得用戶資料

本範例使用的是第一種的方式,我們會透過 LIFF 將 IDtoken 傳遞至後端,流程如下:

  1. 前端透過 LIFF SDK 取得 ID Token
  2. 傳送 ID Token 至後端
  3. 後端向 LINE 平台驗證 ID Token
  4. 驗證成功後取得用戶資料
  5. 後端處理並回傳結果

ID token Nest 後端驗證

Supabase 專案創建

Supabase 官方網站

Supabase 是一個開源的後端即服務(BaaS)平台,提供開發者快速建立應用程式所需的後端基礎設施。它整合了 PostgreSQL 資料庫、身份驗證、即時訂閱、儲存空間等功能,讓開發者無需從零搭建後端環境,即可專注於應用邏輯的開發。

為了讓開發流程更清晰,本專案後端採用 Supabase 的 PostgreSQL 服務。資料庫的建立、管理與操作都透過 Supabase 提供的管理介面和工具完成,簡化配置與維護的複雜度。

【 Free plan 】

  • 最多 2 個活躍專案,專案一週未使用將自動暫停
  • 每個專案提供 500 MB 資料庫空間,以及 1 GB 檔案儲存。
  • 每月活躍使用者(MAU):上限 50,000 人

Step 1:創建專案

Supabase 首頁

Database password 的部分設定後要記得

Supabase 專案創建頁面

Step 2:點選左側導覽列中的 Database

Supabase 左側 Database 功能

Step 3:進入 Database 介面後,點選 Create a new table 創建資料表

Supabase 創建資料表

Step 4:創建兩張資料表,分別紀錄驗證碼及用戶資訊

id 使用 LINE User ID 作為唯一值,後端解析 ID Token 後取得。

Supabase 資料表創建紀錄

Step 5:取得 Supabase 請求端點網址及服務金鑰

進入 Supabase 專案的 Settings > API 頁面,複製以下兩項資訊並設定於後端環境變數中:

1. Project URL(API 端點網址)
Supabase 請求端點網址

2. service_role key(服務金鑰)
Supabase 管理者權杖

Nest 後端伺服器

前置作業

安裝必要套件

  • class-validator & class-transformer:用於請求 DTO 的驗證與資料轉換
  • @supabase/supabase-js:Supabase 官方 JavaScript 客戶端函式庫
pnpm i class-validator
pnpm i class-transformer
pnpm i @supabase/supabase-js

環境變數(env)

  • LINE_LOGIN_VERIFY_URL:LINE 官方 ID Token 驗證 API 端點
  • LINE_LOGIN_CLIENT_ID:LINE Login 頻道 ID(非 Messaging API 頻道喔!!)
  • SUPABASE_URL:Supabase 專案連線位址
  • SUPABASE_SERVICE_ROLE_KEY:Supabase 服務端管理權杖

LINE Login 頻道編號

config 啟動驗證

/**
 * 驗證模式
 */
const configSchema = Joi.object({
  line: Joi.object({
    loginChannelId: Joi.string().required(),
    loginVerifyUrl: Joi.string().uri().required(),
  }).required(),

  supabase: Joi.object({
    url: Joi.string().uri().required(),
    serviceRoleKey: Joi.string().required(),
  }).required(),
});

/**
 * 驗證模組
 */
export default () => {
  const config = {
    line: {
      loginChannelId: process.env.LINE_LOGIN_CLIENT_ID,
      loginVerifyUrl: process.env.LINE_LOGIN_VERIFY_URL,
    },
    supabase: {
      url: process.env.SUPABASE_URL,
      serviceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY,
    },
  };

  const { error, value } = configSchema.validate(config, {
    abortEarly: false,
  });

  if (error) throw new Error(`環境變數驗證錯誤: ${error.message}`);

  return value;
};

LINE Login ID Token 驗證服務建立

請求注意事項:

  • Content-Type:application/x-www-form-urlencoded(以表單格式發送請求資料)
  • idToken:需由前端透過 LIFF SDK 的 liff.getIdToken() 方法取得 JWT 格式的 Token,傳遞至後端後,透過 LINE 驗證 API 解析取得用戶資訊

ID Token 解析後主要欄位:

  • sub:用戶的 LINE User ID(唯一識別碼)
  • name:用戶的顯示名稱
  • picture:用戶的頭像圖片網址
  • aud:LINE Login Channel ID
  • exp:Token 過期時間
// 略
@Injectable()
export class LineLoginService {
  // 略
  async verifyIDToken(idToken: string): Promise<TokenVerifyResponse> {
    const formData = new URLSearchParams();
    formData.append('id_token', idToken);
    formData.append(
      'client_id',
      this.configService.getOrThrow<string>('line.channelId'),
    );

    const responseData = await firstValueFrom(
      this.httpService
        .post(this.LINE_LOGIN_VERIFY_URL, formData.toString(), {
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
          },
        })
        .pipe(
          catchError((err: AxiosError) => {
            return throwError(
              () =>
                new Error(
                  `LINE token verify API request failed: ${JSON.stringify(err.response?.data)}`,
                ),
            );
          }),
        ),
    );

    return responseData.data;
  }
}

Supabase 資料庫服務建立

建立 Supabase 連線物件,供其他服務注入使用。為簡化操作,資料表未使用 RLS 機制,直接使用 Service Role Key 以管理者權限操作。

// 略
import { createClient, SupabaseClient } from '@supabase/supabase-js';

@Injectable()
export class SupabaseService {
  private readonly client: SupabaseClient;

  constructor(private readonly config: ConfigService) {
    const url = this.config.getOrThrow<string>('supabase.url');
    const key = this.config.getOrThrow<string>('supabase.serviceRoleKey');
    this.client = createClient(url, key, { auth: { persistSession: false } });
  }

  get db() {
    return this.client;
  }
}

LINE 使用者註冊服務建立

負責處理用戶相關的 API 邏輯,包含用戶註冊功能。前端透過 LIFF 以 Ajax 方式向後端發送請求。

建立 DTO 資料傳輸物件

使用 class-validator 動態驗證請求及回覆資料,確保符合規定格式。

請求參數說明

  • idToken:前端透過 LIFF SDK 的 liff.getIdToken() 取得
  • name、phone、birthday、email:註冊表單填寫的用戶資料

user/dto/register-user.dto.ts(請求資料)

import {
  IsEmail,
  IsNotEmpty,
  IsString,
  Matches,
  MinLength,
  MaxLength,
} from 'class-validator';

export class RegisterUserDto {
  @IsString()
  @IsNotEmpty({ message: 'LINE ID Token 為必填項目' })
  idToken: string;
  
  @IsString()
  @IsNotEmpty({ message: '姓名為必填項目' })
  @MinLength(2, { message: '姓名至少需要 2 個字元' })
  @MaxLength(20, { message: '姓名不能超過 20 個字元' })
  name: string;

  @IsString()
  @IsNotEmpty({ message: '電話為必填項目' })
  @Matches(/^09\d{8}$/, {
    message: '請輸入正確的台灣手機號碼格式 (09xxxxxxxx)',
  })
  phone: string;

  @IsString()
  @IsNotEmpty({ message: '生日為必填項目' })
  birthday: string;

  @IsEmail({}, { message: '請輸入正確的電子信箱格式' })
  @IsNotEmpty({ message: '電子信箱為必填項目' })
  email: string;
}

在 Controller 透過 DTO 進行請求驗證與資料轉換

使用 ValidationPipe 搭配 DTO 自動驗證請求資料格式。

// 略

@Controller('users')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Post('register')
  @UsePipes(new ValidationPipe({ transform: true }))
  async register(
    @Body() registerUserDto: RegisterUserDto,
  ): Promise<UserResponseDto> {
    return this.userService.register(registerUserDto);
  }
}

建立用戶註冊 API 服務

line-login/line-login.service.ts

// 略

@Injectable()
export class LineLoginService {
  // 略
  async verifyIDToken(idToken: string): Promise<TokenVerifyResponse> {
    const formData = new URLSearchParams();
    formData.append('id_token', idToken);
    formData.append('client_id', this.LINE_LOGIN_CLIENT_ID);

    const responseData = await firstValueFrom(
      this.httpService
        .post(this.LINE_LOGIN_VERIFY_URL, formData.toString(), {
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
          },
        })
        .pipe(
          catchError((err: AxiosError) => {
            return throwError(
              () =>
                new Error(
                  `LINE token verify API request failed: ${JSON.stringify(err.response?.data)}`,
                ),
            );
          }),
        ),
    );

    return responseData.data;
  }
}

本日結語

最後兩天打算完成一個 LIFF 串到後端資料庫的範例,了解後端解析 ID Token 的流程,並透過實作展示 LIFF 與後端的完整互動過程。


上一篇
Day 27:群組事件與檔案訊息接收
下一篇
Day29:LIFF 會員註冊 - 信箱驗證與 Swagger 文件整合
系列文
Line Bot × NestJS:30 天開發日記29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言